第19天!終於快結束了!另外現在正逢大量連假出沒,出遊之餘肯定更有餘裕寫文章的..吧?不管了!昨天我們對後端的部分做了一個小小的優化,利用自己實踐的錯誤重試系統讓我們的應用程式稍稍增加了一點可靠性,並在前端做出了對應的 UI 調整,測試結果也相當不錯,那我們就可以繼續下一步啦!
到現在我們的核心功能已經基本上都可以運作了,透過整合的 prompt 模板和不同工具的調用,我們的 AI 面試官已經可以給出相當有水準的回覆了,但一路做到這邊來,我們其實累積了不少的小錯誤都沒處理掉,這兩天就會趁這個機會,在我們導入使用者認證資料前先把一些明顯的問題修一修!那我們事不宜遲,馬上開始吧!
這個問題其實我第一週整合後就想修正了,但那時候已經在介紹 RAG ,我偷偷改這邊又要去修改 commit 的東西,後來想想就先放棄了。實際上這個問題相當簡單就能修正,目前我們的題庫 api /app/api/questions/route.ts
相當的簡單,就是單純選一題然後丟給你就完事了。
import { NextResponse } from 'next/server';
import path from 'path';
import { promises as fs } from 'fs';
export async function GET() {
try {
// 找到 public 資料夾的路徑
const jsonDirectory = path.join(process.cwd(), 'data');
// 讀取 JSON 檔案
const fileContents = await fs.readFile(
jsonDirectory + '/questions.json',
'utf8'
);
// 解析 JSON 內容
const questions = JSON.parse(fileContents);
// 從題庫中隨機選一題
const randomQuestion =
questions[Math.floor(Math.random() * questions.length)];
return NextResponse.json(randomQuestion);
} catch (error) {
console.error(error);
return NextResponse.json({ error: '無法讀取題庫' }, { status: 500 });
}
}
我們在這個檔案要加的修改也不過是在呼叫時加入題型和主題的參數,讓它在資料庫篩選時(是的,我們暫時還叫這個json檔案資料庫)能正確的回覆篩出的題目。
修改後完整的檔案會變為以下的內容:
import { NextResponse } from 'next/server';
import path from 'path';
import { promises as fs } from 'fs';
import { Question } from '@/app/types/question';
export async function GET(request: Request) {
try {
// 從 URL 查詢參數中取得 type 和 topic 參數
const { searchParams } = new URL(request.url);
const type = searchParams.get('type');
const topic = searchParams.get('topic');
// 找到 public 資料夾的路徑
const jsonDirectory = path.join(process.cwd(), 'data');
// 讀取 JSON 檔案
const fileContents = await fs.readFile(
jsonDirectory + '/questions.json',
'utf8'
);
// 解析 JSON 內容
const questions = JSON.parse(fileContents);
// 過濾題目
const filteredQuestions = questions.filter((question: Question) => {
let matches = true;
// 如果指定了 topic,按 topic 過濾
if (topic) {
matches = matches && question.topic === topic;
}
// 如果指定了 type,按 type 過濾
if (type && (type === 'code' || type === 'concept')) {
matches = matches && question.type === type;
}
return matches;
});
// 從對應題型的題庫中隨機選一題
const randomQuestion =
filteredQuestions[Math.floor(Math.random() * filteredQuestions.length)];
return NextResponse.json(randomQuestion);
} catch (error) {
console.error(error);
return NextResponse.json({ error: '無法讀取題庫' }, { status: 500 });
}
}
光是這樣還不太夠,我們接著要修改一下mockData.ts
的內容去修正一下我們對sessionId
的設置,改為{topic}-{type}
的格式,這樣前端可以輕易從url中取得需要的參數,接著在呼叫後端 API 拿到正確的題型。
請修改mockData.ts
中mockConceptualTopics
和 mockCodingTopics
的部分:
const mockConceptualTopics = [
{
id: 'javascript-concept', // 原本是js-core
title: 'JavaScript 核心觀念',
description: '深入探討 Hoisting, Closure, Prototype 等基礎。',
progress: 75,
color: 'text-yellow-400',
bgColor: 'bg-yellow-900/20',
ringColor: 'ring-yellow-500',
},
{
id: 'react-concept', // 原本是react-core
title: 'React 基礎與 Hooks',
description: '從元件生命週期到 state 與 effect 的掌握。',
progress: 85,
color: 'text-blue-400',
bgColor: 'bg-blue-900/20',
ringColor: 'ring-blue-500',
},
{
id: 'typescript-concept',
title: 'TypeScript 基礎',
description: '理解型別、泛型與 Interface 的應用。',
progress: 60,
color: 'text-cyan-400',
bgColor: 'bg-cyan-900/20',
ringColor: 'ring-cyan-500',
},
{
id: 'javascript-concept',
title: '網路請求與非同步',
description: '關於 Fetch, Promise 與 async/await 的一切。',
progress: 55,
color: 'text-indigo-400',
bgColor: 'bg-indigo-900/20',
ringColor: 'ring-indigo-500',
},
];
const mockCodingTopics = [
{
id: 'css-code', // 原本是css-layout
title: 'CSS 版面挑戰',
description: '使用 Flexbox 與 Grid 打造複雜響應式版面。',
progress: 70,
color: 'text-pink-400',
bgColor: 'bg-pink-900/20',
ringColor: 'ring-pink-500',
},
{
id: 'javascript-code',
title: '實作 React Hooks',
description: '從零開始打造 useDebounce, useToggle 等工具。',
progress: 85,
color: 'text-blue-400',
bgColor: 'bg-blue-900/20',
ringColor: 'ring-blue-500',
},
{
id: 'javascript-code',
title: '演算法入門',
description: '常見的字串與陣列操作題目。',
progress: 65,
color: 'text-green-400',
bgColor: 'bg-green-900/20',
ringColor: 'ring-green-500',
},
];
藉由這樣的修改讓選擇主題的頁面可以傳入更清楚的seesionId
,最後一步就剩下修改前端的頁面了,請到/app/(main)/interview/[sessionId]/page.tsx
中修改fetchQuestion
函數,完整貼上以下的內容即可:
const fetchQuestion = async () => {
try {
setIsFetchingQuestion(true);
// 解析 sessionId:格式為 {topic}-{type}
const sessionParts = sessionId.split('-');
let apiUrl = '/api/questions';
if (sessionParts.length === 2) {
const [topic, type] = sessionParts;
// 映射 topic 到題庫中實際存在的 topic
const topicMapping: Record<string, string> = {
'javascript': 'JavaScript',
'react': 'React',
'css': 'CSS',
'typescript': 'JavaScript', // TypeScript 題目歸類到 JavaScript
};
const actualTopic = topicMapping[topic] || topic;
const params = new URLSearchParams();
params.append('topic', actualTopic);
params.append('type', type);
apiUrl = `/api/questions?${params.toString()}`;
}
const response = await fetch(apiUrl);
const data: Question = await response.json();
setCurrentQuestion(data);
setChatHistory([
{ role: 'ai', content: '你好!我是你的 AI 前端面試官。' },
{ role: 'ai', content: `第一題:**${data.question}**` },
]);
if (data.type === 'code' && data.starterCode) {
setAnswer(data.starterCode);
} else {
setAnswer('');
}
} catch (error) {
console.error('無法抓取題目:', error);
setChatHistory([{ role: 'ai', content: '抱歉,載入題目時發生錯誤。' }]);
} finally {
setIsFetchingQuestion(false);
}
};
fetchQuestion();
}, [sessionId]);
這麼一來你選擇對應的主題與練習類型後,就再也不會牛頭不對馬嘴了。
![]() |
---|
圖1 :正確的測驗頁面 |
第二個問題可能用文字描述會有點抽象,簡單說就是如下圖所示:
![]() |
---|
圖2 :回應中途會看到JSON格式問題 |
由於是採取 Streaming的方式回覆,在我們目前的設計中,我們是將所有回應的content拼湊起來,但沒有考慮到中途使用者看到的會是一個拼湊的 JSON 物件,我們在解析 Streaming 資料時應該要針對裡面的 summary屬性做抽取的處理,讓過程中能直接看到回覆的內容,而不是奇怪的 JSON 物件。
請再次回到/app/(main)/interview/[sessionId]/page.tsx
中,將handleSubmit
函數最後面的部分做這樣的修改:
const handleSubmit = async () => {
// 上略
// ...
// 持續解碼並更新最後一條 AI 訊息的 content
accumulatedResponse += decoder.decode(value, { stream: true });
// 嘗試解析部分 JSON 來提取 summary,如果失敗就顯示載入訊息
let displayContent = '正在分析中...';
try {
const partialJson = JSON.parse(accumulatedResponse);
if (partialJson.summary) {
displayContent = partialJson.summary;
}
} catch {
// JSON 還未完整,繼續顯示載入訊息
displayContent = '正在分析中...';
}
setChatHistory((prevHistory) => {
const newHistory = [...prevHistory];
newHistory[newHistory.length - 1].content = displayContent;
return newHistory;
});
}
這麼一來, 新的邏輯就變為
再次提交一次測試的答案,你會發現流程與我們想像的完全相同了!
![]() |
---|
圖3 :修復後的正確回應 |
最後一個問題則是我們在部署時的疏失,之前在 Day7 時我們的部署是完全沒有問題的,但後續我們新增了一些第三方服務的使用,也因此需要更新環境變數的使用,請將你之前存在.evn.local
的環境變數全都照之前的配置,設置在vercel專案中,目前你的環境變數應該包含以下的變數
GEMINI_API_KEY=你的GEMINI_API_KEY
SUPABASE_URL=你的SUPABASE_URL
SUPABASE_SERVICE_KEY=你的SUPABASE_SERVICE_KEY
JUDGE0_API_HOST=你的JUDGE0_API_HOST
JUDGE0_API_KEY=你的JUDGE0_API_KEY
修正後再次部署或是透過推送commit觸發CI/CD,你應該就不會看到部署失敗的問題再出現了!
今天我們修正了幾個惱人的問題,讓我們在後續整合最後幾個頁面前還了一些技術債。
✅ 修改sessionId
的格式與後端服務,修正題庫與主題對不上的問題
✅ 修正解析 Streaming 過程時,會看到不該看到的 JSON 格式問題
✅ 在vercel補上我們這兩週新增的環境變數,修正部署失敗問題
很好! 現在功能更正常一點了,但我們離收尾還有一段距離,目前的evaluate
api相當的臃腫,之後還要加進使用者相關的邏輯,我們不得不在那之前做點手腳了,明天的內容會集中在那個檔案的模組化,讓使用上更為簡潔方便一些!
我們明天見!
今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-19